package main

import (
	"flag"
	"fmt"
	"io/fs"
	"os"
	"os/exec"
	"path/filepath"
	"regexp"
	"strings"
)

const (
	protoExt     = ".proto"
	protoc       = "protoc"
	protoPathCfg = "--proto_path="
	protoPlugin  = "--go_out=plugins=grpc,paths=source_relative:./generated"
)

var (
	// 一定不会保存 .proto 文件的目录
	globalExcludeDir = []string{"generated", "generated_java", "out", "target", ".git", ".idea"}
)

var (
	global      bool
	path        string
	excludePath string
)

func init() {
	// 简写
	flag.BoolVar(&global, "g", false, "是否启动全局检索")
	flag.StringVar(&path, "p", "", `编译文件路径 (可以是文件夹，也可以是文件名；路径请使用 '/' 符号分隔；多个路径间通过逗号分开)
使用示例：
[1] go run builder.go -p ai/face_rkg/face_rkg.proto (按路径检索，编译 face_rkg.proto 文件)
[2] go run builder.go -p ai/face_rkg (按路径检索，编译 face_rkg 文件夹)
[3] go run builder.go -p ai/face_rkg/face_rkg.proto,ai/object_detect (按路径检索，编译 face_rkg.proto 文件与 object_detect 文件夹)
[4] go run builder.go -p ai/face_rkg -ex=monitor,query (按路径检索，编译 face_rkg 文件夹下，名字不含 monitor 或 query 的文件)
[5] go run builder.go -g -p=/ (全局检索，编译所有文件)
[6] go run builder.go -g -p rkg,athena -ex query (全局检索，编译路径中包含 rkg 或 athena，且不包含 query 的文件)
	`)
	flag.StringVar(&excludePath, "ex", "", "编译文件路径下，不包括的文件 (正则匹配文件名；多个路径间通过逗号分开)")
}

func main() {
	// 输入参数解析
	flag.Parse()
	if len(path) == 0 {
		fmt.Println("必须输入编译文件路径，可以通过 go run builder.go -h 查看输入参数提示.")
		return
	}
	// 找到所有符合要求的 proto 文件 (绝对路径模式/全局模式)
	var (
		protoSet map[string]struct{}
		paths    = strings.Split(path, ",")
		excludes = strings.Split(excludePath, ",")
	)
	if isAllFilePath(path) {
		protoSet = findProtoGlobal([]string{".*"}, excludes)
	} else {
		if global {
			protoSet = findProtoGlobal(paths, excludes)
		} else {
			protoSet = findProtoAbs(paths, excludes)
		}
	}
	if len(protoSet) == 0 {
		fmt.Println("没有符合要求的文件名，编译结束.")
		os.Exit(0)
	}
	// 将 proto 文件名集合转为便于阅读的字符串
	var (
		input     string
		success   int
		protoName strings.Builder
	)
	for proto := range protoSet {
		protoName.WriteString(proto)
		protoName.WriteByte('\n')
	}
	// 等待用户确认待编译的文件名，并检查用户输入
	fmt.Printf("请确认等待编译的文件，如确认无误，请输入 'y' 或 'yes'：\n%v\n", protoName.String())
	_, _ = fmt.Scan(&input)
	if !correctConfirm(input) {
		fmt.Println("输入的确认字符有误，程序退出.")
		os.Exit(1)
	}
	// 调用 protoc 逐个编译文件
	var (
		wd, _     = os.Getwd()
		protoPath = protoPathCfg + wd
	)
	for proto := range protoSet {
		ex := exec.Command(protoc, protoPath, protoPlugin, proto)
		if _, err := ex.CombinedOutput(); err != nil {
			fmt.Printf("编译文件 [%v] 失败，错误：%+v", proto, err)
		} else {
			success++
		}
	}
	fmt.Printf("程序执行完成，共有 %v 个文件需要编译， %v 个文件编译成功.\n", len(protoSet), success)
}

func isAllFilePath(p string) bool {
	return p == "*" || p == "/" || p == ".*" || filepath.Clean(p) == "\\"
}

func correctConfirm(v string) bool {
	lv := strings.ToLower(v)
	return len(lv) > 0 && (lv == "y" || lv == "yes")
}

func findProtoAbs(paths, excludes []string) map[string]struct{} {
	set := make(map[string]struct{})
	excludeRegex := matchRegex(excludes)
	for _, p := range paths {
		// 清理无效路径
		if len(p) == 0 {
			continue
		}
		// 清除路径开头的 / 符号
		var i int
		for ; i < len(p); i++ {
			if p[i] != '/' {
				break
			}
		}
		p = filepath.Clean(p[i:])
		// 判断是否整体被排除，如果没有则加入文件列表
		if len(excludeRegex) > 0 && match(excludeRegex, p) {
			continue
		}
		// 判断是文件还是路径
		var isFile bool
		if len(p) > 6 && filepath.Ext(p) == protoExt {
			isFile = true
		}
		if isFile && exists(p) {
			set[filepath.ToSlash(p)] = struct{}{}
		} else {
			_ = filepath.WalkDir(p, func(path string, d fs.DirEntry, err error) error {
				if d == nil || err != nil {
					return err
				}
				if len(excludeRegex) > 0 && match(excludeRegex, path) {
					if d.IsDir() {
						return filepath.SkipDir
					}
					return nil
				}
				if !d.IsDir() && filepath.Ext(path) == protoExt {
					set[filepath.ToSlash(path)] = struct{}{}
				}
				return nil
			})
		}
	}
	return set
}

func findProtoGlobal(paths, excludes []string) map[string]struct{} {
	for i := 0; i < len(paths); i++ {
		paths[i] = strings.Replace(paths[i], "/", "\\\\", -1)
	}
	set := make(map[string]struct{})
	excludes = append(excludes, globalExcludeDir...)
	pathRegex := matchRegex(paths)
	excludeRegex := matchRegex(excludes)
	_ = filepath.WalkDir(filepath.Join("./"), func(path string, d fs.DirEntry, err error) error {
		if d == nil || err != nil {
			return err
		}
		if len(excludeRegex) > 0 && match(excludeRegex, path) {
			if d.IsDir() {
				return filepath.SkipDir
			}
			return nil
		}
		if !d.IsDir() && filepath.Ext(path) == protoExt && match(pathRegex, path) {
			set[filepath.ToSlash(path)] = struct{}{}
		}
		return nil
	})
	return set
}

func matchRegex(patterns []string) string {
	if len(patterns) == 0 {
		return ""
	}
	var builder strings.Builder
	for _, p := range patterns {
		if len(p) == 0 {
			continue
		}
		builder.WriteByte('(')
		builder.WriteString(p)
		builder.WriteString(")|")
	}
	s := builder.String()
	if len(s) == 0 {
		return ""
	}
	return s[0 : len(s)-1]
}

func match(pattern, path string) bool {
	b, err := regexp.MatchString(pattern, path)
	return err == nil && b
}

func exists(fileName string) bool {
	var exist = true
	if _, err := os.Stat(fileName); os.IsNotExist(err) {
		exist = false
	}
	return exist
}
